Skip to content

Pre-encoded H.265: length-prefixed ingest, pacer tuning, leaner publish path - #1579

Open
haffi96 wants to merge 3 commits into
livekit:mainfrom
haffi96:feat/h265-pre-encoded-length-prefixed
Open

haffi96 wants to merge 3 commits into
livekit:mainfrom
haffi96:feat/h265-pre-encoded-length-prefixed

Conversation

@haffi96

@haffi96 haffi96 commented Oct 27, 2026 •

Copy link
Copy Markdown

Hardware encoder bridges can often emit H.265 as 4-byte length-prefixed NAL units (hev1), which the Rust SDK could not publish pre-encoded. Once publishing worked, these tracks also sat in the pacer for seconds after publishing, and the publish path did redundant work on every frame. This PR fixes all three, one commit per stage, each with its own changeset:

1. Publish length-prefixed pre-encoded H.265

  • livekit-capture: LengthPrefixedAccessUnitParser (previously test-only, H.264 only) handles H.264 and H.265, with an opt-in single-slice flush that completes a picture as soon as its slice arrives instead of a frame later. Adds access_unit_from_h265_hvcc, hev1 caps in the GStreamer source, and frame_metadata_from_sei for the LKTS packet-trailer SEI (the Go SDK's ReaderTrackWithPacketTrailer convention).
  • livekit: single-layer video tracks announce their codec in simulcast_codecs, as the Go SDK does.
  • local_video: new encoded_publisher example that publishes a length-prefixed stream received over TCP.

2. Let pre-encoded tracks leave the pacer promptly
A pre-encoded stream arrives at its encoder's bitrate, but the starting estimate is capped at 1 Mbps, so packets queued in the pacer for ~8 s after publishing (~510 ms per packet at the peak for 1080p30 at 4 Mbps).

  • webrtc-sys: read field trials from LIVEKIT_WEBRTC_FIELD_TRIALS. With WebRTC-Video-Pacing/factor:10/ the startup delay drops to ~4 ms, then ~0.
  • livekit: pre-encoded tracks are exempt from the starting-estimate cap, like screen share (peak ~510 ms → ~210–440 ms on its own).
  • encoded_publisher --stats logs codec, frames sent and pacer delay from standard WebRTC stats.

3. Trim the publish path

  • The length-prefixed parser copied each access unit twice and rescanned it byte by byte; it now converts in place and classifies from known NAL ranges: 321 µs → 3.4 µs per 1080p access unit. find_start_code also skips between 0x01 bytes, which speeds up every Annex-B path (an exhaustive test pins it to the old behaviour).
  • Pre-encoded video is published as a single layer. FFI clients such as the C++ SDK leave simulcast on by default; the pass-through then announced 360p/180p layers carrying copies of the full stream and requested a keyframe on almost every frame (352 requests in 15 s, vs 28 after).

Testing

  • cargo test -p livekit-capture --features source-gstreamer (55 pass), cargo test -p livekit --lib, cargo test -p webrtc-sys --lib.
  • Manual: gst-launch-1.0 videotestsrc ! nvcudah265enc ! h265parse config-interval=-1 ! video/x-h265,stream-format=hev1,alignment=au ! tcpserversink into encoded_publisher --single-slice-flush --stats against livekit-server --dev. Cross-checked end to end with a packet capture and a subscriber: steady-state publish latency p50 0.84 ms (Go SDK: 0.49 ms).
  • Also verified with a GStreamer camera pipeline that sends length-prefixed H.265 with SEI timestamp metadata over TCP, published with --single-slice-flush --packet-trailer to livekit-server --dev and LiveKit Cloud; the subscriber received 720p30 with the user timestamp and frame ID intact.

Notes

  • Tested on Linux with NVENC/NVDEC and on macOS (Apple Silicon) with VideoToolbox. On a host without a local H.265 encoder, H.265 also needs jetson h265: encoder #1451 to be negotiable.

Created with Claude Opus 5.5 in Claude Code.

devin-ai-integration[bot]

This comment was marked as resolved.

@haffi96
haffi96 marked this pull request as draft October 27, 2026 09:20
@haffi96

haffi96 commented Oct 27, 2026 •

Copy link
Copy Markdown
Author

The main motivation here is to enable the H265 pre-encoded ingest path with length prefixed NAL units more or less trying to mirror Livekit Go SDK pre-encoded publish path

Along the way, benchmarking the changes against a Go publisher client, I found that there was differences in the pacer logic in libwebrtc and some smaller gains in the publish path that could be made. I've addressed those here in a separate commits so they can easily be excluded if risky

This PR can probably be broken down but I chose to put them all in one as they go hand in hand for testing/verification. Happy to break this down into a smaller piece or whatever is preferable

haffi96 and others added 3 commits October 30, 2026 09:26
Pre-encoded H.265 could only be ingested as Annex-B, and a single-layer
pre-encoded H.265 track could not be published at all.

- livekit-capture: `LengthPrefixedAccessUnitParser` (was the test-only
  AVC parser) handles H.264 (avc3) and H.265 (hev1) length-prefixed
  streams, with an opt-in single-slice flush that completes an access
  unit as soon as its slice arrives instead of a frame later.
- livekit-capture: `access_unit_from_h265_hvcc` mirrors
  `access_unit_from_h264_avc`; the GStreamer source accepts `hev1` caps.
- livekit-capture: `frame_metadata_from_sei` reads the LKTS
  packet-trailer SEI (the Go SDK `ReaderTrackWithPacketTrailer`
  convention) for the pump's frame metadata callback.
- livekit: announce the codec of single-layer video tracks in
  AddTrackRequest. Without it the SFU answered with its default (VP8),
  so a pre-encoded H.265 track negotiated VP8 and the pass-through
  dropped every access unit. The Go SDK always sends it.
- local_video: `encoded_publisher` publishes a length-prefixed stream
  received over TCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libwebrtc paces packets at 2.5x its bandwidth estimate, which starts at
1 Mbps for camera tracks. A pre-encoded stream arrives at the bitrate its
upstream encoder chose and cannot slow down, so for the first ~9 s frames
queued in the pacer: up to ~420 ms (Rust) / ~570 ms (C++) at p99 for a
1080p30 ~5 Mbps H.265 stream, and 16-50 ms after each keyframe in some
runs once the estimate settled.

- webrtc-sys: read field trials from LIVEKIT_WEBRTC_FIELD_TRIALS
  ("Name/Value/" format), taking precedence over the SDK's own. With
  "WebRTC-Video-Pacing/factor:10/" startup p99 drops to ~1.5-2.2 ms.
- livekit: exempt pre-encoded tracks from the 1 Mbps cap on
  x-google-start-bitrate, like screen share (halves the startup penalty
  on its own).
- local_video: `encoded_publisher --stats` logs the pacer delay per
  packet (totalPacketSendDelay / packetsSent), target bitrate and
  bandwidth estimate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- livekit-capture: the length-prefixed parser copied each access unit
  twice and rescanned it byte by byte for start codes to classify the
  frame (~205 us per 1080p access unit). It now converts in place during
  one copy and classifies from the NAL ranges it already has (~2.5 us).
  find_start_code jumps between 0x01 bytes instead of matching at every
  offset, which speeds up every Annex-B path; an exhaustive test pins it
  to the old behaviour. Steady-state publish latency p50 1.21 -> 0.81 ms.
- livekit: publish pre-encoded video without simulcast. FFI clients
  such as the C++ SDK leave simulcast on by default; the track then
  announced 360p/180p layers whose senders forwarded copies of the full
  stream, requested keyframes on almost every frame, and competed with
  the real layer in the pacer.
- local_video: `encoded_publisher` counts keyframe requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@haffi96
haffi96 force-pushed the feat/h265-pre-encoded-length-prefixed branch from 9a574e8 to b94d889 Compare October 30, 2026 09:27
@haffi96
haffi96 marked this pull request as ready for review October 30, 2026 13:57

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant